Põhjalik juhend ülemaailmsetele arendajatele samaaegsuse kontrolli kohta. Avastage lukupõhist sünkroonimist, mutekseid, semafore, ummikseise ja parimaid praktikaid.
Sünkroniseerimise valdamine: Süvitsi lukupõhisesse sünkroonimisse
Kujutage ette elavat professionaalset kööki. Mitmed kokad töötavad samaaegselt, kõik vajavad juurdepääsu jagatud koostisosade sahvrile. Kui kaks kokka proovivad täpselt samal hetkel haarata viimast purki haruldase vürtsiga, siis kes selle saab? Mis siis, kui üks kokk uuendab retseptikaarti, samal ajal kui teine seda loeb, mis viib pooliku, mõttetu juhiseni? See köögi kaos on ideaalne analoogia kaasaegse tarkvaraarenduse peamisele väljakutsele: samaaegsus.
Tänapäeva mitmetuumaliste protsessorite, hajutatud süsteemide ja väga reageerivate rakenduste maailmas ei ole samaaegsus – erinevate programmi osade võime käivituda väljaspool järjekorda või osalises järjekorras, ilma et see mõjutaks lõpptulemust – luksus; see on vajadus. See on kiirete veebiserverite, sujuvate kasutajaliideste ja võimsate andmetöötlusliinide mootor. Kuid selle võimsusega kaasneb märkimisväärne keerukus. Kui mitmed lõimed või protsessid pääsevad samaaegselt jagatud ressurssidele juurde, võivad need üksteist häirida, mis võib viia andmete riknemiseni, ettearvamatule käitumisele ja kriitilistele süsteemiriketele. Siin tuleb mängu samaaegsuse kontroll.
See põhjalik juhend uurib kõige põhjalikumat ja laialdasemalt kasutatavat tehnikat selle kontrollitud kaose haldamiseks: lukupõhine sünkroonimine. Me demüstifitseerime, mis on lukud, uurime nende erinevaid vorme, navigeerime nende ohtlikes lõksudes ja kehtestame ülemaailmsed parimad praktikad jõulise, turvalise ja tõhusa samaaegse koodi kirjutamiseks.
Mis on samaaegsuse kontroll?
Põhimõtteliselt on samaaegsuse kontroll arvutiteaduse distsipliin, mis on pühendatud jagatud andmete samaaegsete toimingute haldamisele. Selle peamine eesmärk on tagada, et samaaegsed toimingud toimiksid õigesti, ilma üksteist häirimata, säilitades andmete terviklikkuse ja järjepidevuse. Mõelge sellele kui köögijuhile, kes kehtestab reeglid, kuidas kokad saavad sahvrile juurde pääseda, et vältida mahavalgumist, segadust ja raisatud koostisosi.
Andmebaaside maailmas on samaaegsuse kontroll oluline ACID-i omaduste (aatomilisus, järjepidevus, isolatsioon, vastupidavus) säilitamiseks, eriti isolatsioon. Isolatsioon tagab, et tehingute samaaegne käivitamine toob kaasa süsteemi oleku, mis saadakse, kui tehinguid teostatakse järjestikku, üksteise järel.
Samaaegsuse kontrolli rakendamiseks on kaks peamist filosoofiat:
- Optimistlik samaaegsuse kontroll: see lähenemine eeldab, et konfliktid on haruldased. See võimaldab toimingutel jätkuda ilma eelnevate kontrollideta. Enne muudatuse kinnitamist kontrollib süsteem, kas mõni muu toiming on vahepeal andmeid muutnud. Kui konflikt tuvastatakse, tühistatakse toiming tavaliselt ja proovitakse uuesti. See on strateegia "vabandust küsi hiljem, mitte luba enne".
- Pessimistlik samaaegsuse kontroll: see lähenemine eeldab, et konfliktid on tõenäolised. See sunnib toimingut hankima ressursile luku, enne kui see sellele juurde pääseb, takistades teistel toimingutel sekkumist. See on strateegia "küsi luba enne, mitte vabandust hiljem".
See artikkel keskendub ainult pessimistlikule lähenemisviisile, mis on lukupõhise sünkroonimise alus.
Põhiprobleem: Võidujooksu olukorrad
Enne kui saame lahendust hinnata, peame probleemist täielikult aru saama. Kõige tavalisem ja salakavalam viga samaaegses programmeerimises on võidujooks. Võidujooks tekib siis, kui süsteemi käitumine sõltub kontrollimatute sündmuste, näiteks operatsioonisüsteemi lõimede ajastamisest, ettearvamatust järjestusest või ajastusest.
Vaatleme klassikalist näidet: jagatud pangakonto. Oletame, et kontol on saldo 1000 dollarit ja kaks samaaegset lõime proovivad kumbki sissemakse teha 100 dollarit.
Siin on sissemakse tegemise lihtsustatud toimingute jada:
- Lugege praegune saldo mälust.
- Lisage sissemakse summa sellele väärtusele.
- Kirjutage uus väärtus tagasi mällu.
Õige jadapõhine käivitamine tooks kaasa lõppsaldo 1200 dollarit. Aga mis juhtub samaaegses stsenaariumis?
Toimingute potentsiaalne vaheldumine:
- Lõim A: Loeb saldo (1000 dollarit).
- Kontekstilülitus: operatsioonisüsteem peatab lõime A ja käivitab lõime B.
- Lõim B: Loeb saldo (endiselt 1000 dollarit).
- Lõim B: Arvutab oma uue saldo (1000 dollarit + 100 dollarit = 1100 dollarit).
- Lõim B: Kirjutab uue saldo (1100 dollarit) tagasi mällu.
- Kontekstilülitus: operatsioonisüsteem jätkab lõime A.
- Lõim A: Arvutab oma uue saldo varem loetud väärtuse alusel (1000 dollarit + 100 dollarit = 1100 dollarit).
- Lõim A: Kirjutab uue saldo (1100 dollarit) tagasi mällu.
Lõppsaldo on 1100 dollarit, mitte oodatud 1200 dollarit. 100 dollari suurune sissemakse on võidujooksu tõttu haihtunud õhku. Koodiplokki, kus jagatud ressursile (kontosaldo) juurde pääsetakse, nimetatakse kriitiliseks sektsiooniks. Võidujooksu vältimiseks peame tagama, et ainult üks lõim saaks igal ajahetkel kriitilises sektsioonis käivituda. Seda põhimõtet nimetatakse vastastikuseks välistuseks.
Lukupõhise sünkroonimise tutvustamine
Lukupõhine sünkroonimine on peamine mehhanism vastastikuse välistuse jõustamiseks. Lukk (tuntud ka kui mutex) on sünkroonimisprimitiiv, mis toimib kriitilise sektsiooni valvurina.
Analoogia võtmega ühe kasutaja tualetti on väga sobiv. Tualettruum on kriitiline sektsioon ja võti on lukk. Paljud inimesed (lõimed) võivad väljas oodata, kuid ainult võtit hoidev inimene saab siseneda. Kui nad on valmis, väljuvad nad ja tagastavad võtme, võimaldades järgmisel järjekorras oleval inimesel selle võtta ja siseneda.
Lukud toetavad kahte põhilist toimingut:
- Hankimine (või lukustamine): lõim kutsub selle toimingu enne kriitilisse sektsiooni sisenemist. Kui lukk on saadaval, hangib lõim selle ja jätkab. Kui lukku hoiab juba mõni teine lõim, blokeerub (või "magab") kutsuv lõim, kuni lukk vabastatakse.
- Vabastamine (või avamine): lõim kutsub selle toimingu pärast kriitilise sektsiooni käivitamise lõpetamist. See muudab luku teistele ootavatele lõimedele hangitavaks.
Pangakonto loogika lukuga ümbritsedes saame tagada selle õigsuse:
acquire_lock(account_lock);
// --- Kriitilise sektsiooni algus ---
balance = read_balance();
new_balance = balance + amount;
write_balance(new_balance);
// --- Kriitilise sektsiooni lõpp ---
release_lock(account_lock);
Nüüd, kui lõim A hangib luku esimesena, sunnitakse lõim B ootama, kuni lõim A on kõik kolm sammu lõpule viinud ja luku vabastanud. Toimingud ei ole enam vahelduvad ja võidujooks on kõrvaldatud.
Lukutüübid: Programmeerija tööriistakomplekt
Kuigi luku põhikontseptsioon on lihtne, nõuavad erinevad stsenaariumid erinevat tüüpi lukustusmehhanisme. Saadaolevate lukkude tööriistakomplekti mõistmine on ülioluline tõhusate ja korrektsete samaaegsete süsteemide ehitamiseks.
Mutex (Vastastikuse välistuse) lukud
Mutex on kõige lihtsam ja tavalisem lukutüüp. See on binaarne lukk, mis tähendab, et sellel on ainult kaks olekut: lukustatud või avatud. See on loodud range vastastikuse välistuse jõustamiseks, tagades, et ainult üks lõim saab lukku korraga omada.
- Omandiõigus: Enamiku mutex-rakenduste peamine tunnus on omandiõigus. Lõim, mis hangib muteksi, on ainus lõim, millel on lubatud seda vabastada. See takistab ühel lõimel kogemata (või pahatahtlikult) avamast teise lõime kasutatavat kriitilist sektsiooni.
- Kasutusjuhtum: Muteksid on vaikevalik lühikeste, lihtsate kriitiliste sektsioonide kaitsmiseks, nagu jagatud muutuja värskendamine või andmestruktuuri muutmine.
Semaforid
Semafor on üldisem sünkroonimisprimitiiv, mille leiutas Hollandi arvutiteadlane Edsger W. Dijkstra. Erinevalt muteksist säilitab semafor mittenegatiivse täisarvu loendurit.
See toetab kahte aatomioperatsiooni:
- wait() (või P-toiming): vähendab semafori loendurit. Kui loendur muutub negatiivseks, blokeerub lõim, kuni loendur on suurem või võrdne nulliga.
- signal() (või V-toiming): suurendab semafori loendurit. Kui semaforil on blokeeritud lõimi, vabastatakse üks neist.
Semaforeid on kahte peamist tĂĽĂĽpi:
- Binaarne semafor: loendur on initsialiseeritud väärtusega 1. See võib olla ainult 0 või 1, muutes selle funktsionaalselt samaväärseks muteksiga.
- Loendussemafor: loendurit saab initsialiseerida mis tahes täisarvuga N > 1. See võimaldab kuni N lõimel ressursile samaaegselt juurde pääseda. Seda kasutatakse juurdepääsu kontrollimiseks piiratud ressursside kogumile.
Näide: kujutage ette veebirakendust, millel on ühendusekogum, mis suudab hallata maksimaalselt 10 samaaegset andmebaasiühendust. 10-ga initsialiseeritud loendussemafor saab seda suurepäraselt hallata. Iga lõim peab enne ühenduse võtmist tegema semaforil `wait()`. Üheteistkümnes lõim blokeerub, kuni üks esimesest kümnest lõimest lõpetab oma andmebaasitöö ja teeb semaforil `signal()`, tagastades ühenduse kogumile.
Lugemis-kirjutamis lukud (jagatud/eksklusiivsed lukud)
Samaaegsetes süsteemides on tavaline muster, et andmeid loetakse palju sagedamini kui kirjutatakse. Lihtsa muteksi kasutamine selles stsenaariumis on ebatõhus, kuna see takistab mitmel lõimel andmeid samaaegselt lugeda, kuigi lugemine on turvaline, mitte-muutev toiming.
Lugemis-kirjutamis lukk lahendab selle, pakkudes kahte lukustusreĹľiimi:
- Jagatud (lugemis)lukk: mitu lõime saavad samaaegselt hankida lugemisluku, kui ükski lõim ei hoia kirjutamislukku. See võimaldab suure samaaegsusega lugemist.
- Eksklusiivne (kirjutamis)lukk: korraga saab kirjutamisluku hankida ainult üks lõim. Kui lõim hoiab kirjutamislukku, blokeeritakse kõik muud lõimed (nii lugejad kui ka kirjutajad).
Analoogia on dokument jagatud raamatukogus. Paljud inimesed saavad samaaegselt lugeda dokumendi koopiaid (jagatud lugemislukk). Kui aga keegi soovib dokumenti redigeerida, peab ta selle eranditult välja võtma ja keegi teine ei saa seda lugeda ega redigeerida, kuni ta on valmis (eksklusiivne kirjutamislukk).
Rekursiivsed lukud (taasrentuvad lukud)
Mis juhtub, kui lõim, mis juba hoiab muteksi, proovib seda uuesti hankida? Standardse muteksiga tooks see kaasa kohese ummikseisu – lõim ootaks igavesti, kuni ta ise luku vabastab. Rekursiivne lukk (või Taasrentuv lukk) on loodud selle probleemi lahendamiseks.
Rekursiivne lukk võimaldab samal lõimel sama lukku mitu korda hankida. See säilitab sisemise omandiõiguse loenduri. Lukk vabastatakse täielikult alles siis, kui omav lõim on kutsunud `release()` sama palju kordi kui `acquire()`. See on eriti kasulik rekursiivsetes funktsioonides, mis peavad oma käivitamise ajal jagatud ressurssi kaitsma.
Lukustamise ohud: levinud lõksud
Kuigi lukud on võimsad, on need kahe teraga mõõk. Lukkude ebaõige kasutamine võib põhjustada vigu, mida on palju raskem diagnoosida ja parandada kui lihtsaid võidujooksu. Nende hulka kuuluvad ummikseisud, elulukud ja jõudluse kitsaskohad.
Ummikseis
Ummikseis on kõige kardetum stsenaarium samaaegses programmeerimises. See tekib siis, kui kaks või enam lõime on määramata ajaks blokeeritud, oodates igaüks ressurssi, mida hoiab teine lõim samas komplektis.
Vaatleme lihtsat stsenaariumi kahe lõimega (Lõim 1, Lõim 2) ja kahe lukuga (Lukk A, Lukk B):
- Lõim 1 hangib luku A.
- Lõim 2 hangib luku B.
- Lõim 1 proovib nüüd hankida luku B, kuid seda hoiab lõim 2, seega lõim 1 blokeerub.
- Lõim 2 proovib nüüd hankida luku A, kuid seda hoiab lõim 1, seega lõim 2 blokeerub.
Mõlemad lõimed on nüüd kinni püsivas ooteseisundis. Rakendus peatub. See olukord tuleneb nelja vajaliku tingimuse olemasolust (Coffmani tingimused):
- Vastastikune välistus: ressursse (lukke) ei saa jagada.
- Hoidmine ja ootamine: lõim hoiab vähemalt ühte ressurssi, oodates samal ajal teist.
- Eelvalimine puudub: ressurssi ei saa jõuga võtta lõimelt, mis seda hoiab.
- Ringootamine: eksisteerib kett kahest või enamast lõimest, kus iga lõim ootab ressurssi, mida hoiab ketis järgmine lõim.
Ummikseisu vältimine hõlmab vähemalt ühe neist tingimustest murdmist. Kõige tavalisem strateegia on ringootamistingimuse murdmine, jõustades luku hankimise jaoks range ülemaailmse järjekorra.
Elulukk
Elulukk on ummikseisu peenem sugulane. Elulukus ei ole lõimed blokeeritud – need töötavad aktiivselt –, kuid nad ei tee edusamme. Nad on kinni ahelas, reageerides üksteise olekumuutustele, ilma et nad saavutaksid mingit kasulikku tööd.
Klassikaline analoogia on kaks inimest, kes proovivad üksteisest kitsas koridoris mööduda. Mõlemad püüavad olla viisakad ja astuvad vasakule, kuid blokeerivad üksteist. Seejärel astuvad mõlemad paremale, blokeerides üksteist uuesti. Nad liiguvad aktiivselt, kuid ei edene koridoris. Tarkvaras võib see juhtuda halvasti kujundatud ummikseisu taastemehhanismidega, kus lõimed korduvalt tagasi tõmbuvad ja uuesti proovivad, ainult et uuesti konfliktis olla.
Näljutamine
Näljutamine tekib siis, kui lõimelt pidevalt keelatakse juurdepääs vajalikule ressursile, kuigi ressurss muutub kättesaadavaks. See võib juhtuda süsteemides, kus on ajastamisalgoritmid, mis ei ole "õiglased". Näiteks kui lukustusmehhanism annab alati juurdepääsu kõrge prioriteediga lõimedele, ei pruugi madala prioriteediga lõim kunagi võimalust saada, kui on pidev kõrge prioriteediga võistlejate voog.
Jõudluse üldkulud
Lukud ei ole tasuta. Need toovad kaasa jõudluse üldkulusid mitmel viisil:
- Hankimise/vabastamise hind: luku hankimine ja vabastamine hõlmab aatomioperatsioone ja mälupiirdeid, mis on arvutuslikult kallimad kui tavalised juhised.
- Konkurents: kui mitu lõime konkureerivad sageli sama luku pärast, kulutab süsteem märkimisväärse osa ajast kontekstilülitusele ja lõimede ajastamisele, mitte tootliku töö tegemisele. Kõrge konkurents jadab käivitamise tõhusalt, nurjates paralleelsuse eesmärgi.
Lukupõhise sünkroonimise parimad praktikad
Õige ja tõhusa samaaegse koodi kirjutamine lukkudega nõuab distsipliini ja parimate tavade järgimist. Need põhimõtted on universaalselt rakendatavad, olenemata programmeerimiskeelest või platvormist.
1. Hoidke kriitilised sektsioonid väikesed
Lukku tuleks hoida võimalikult lühikest aega. Teie kriitiline sektsioon peaks sisaldama ainult koodi, mida on absoluutselt vaja kaitsta samaaegse juurdepääsu eest. Kõik mittekriitilised toimingud (nagu sisend/väljund, keerulised arvutused, mis ei hõlma jagatud olekut) tuleks teha väljaspool lukustatud piirkonda. Mida kauem lukku hoiate, seda suurem on konkurentsi oht ja seda rohkem blokeerite teisi lõimi.
2. Valige õige luku granularsuse tase
Luku granularsuse tase viitab andmete hulgale, mida kaitseb ĂĽks lukk.
- Jämedateraline lukustamine: ühe luku kasutamine suure andmestruktuuri või terve alamsüsteemi kaitsmiseks. Seda on lihtsam rakendada ja selle üle arutleda, kuid see võib põhjustada kõrget konkurentsi, kuna seotud toimingud andmete erinevates osades jadastatakse sama lukuga.
- Peeneteraline lukustamine: mitme luku kasutamine andmestruktuuri erinevate, sõltumatute osade kaitsmiseks. Näiteks hash-tabeli jaoks ühe luku asemel võiksite iga konteineri jaoks kasutada eraldi lukku. See on keerulisem, kuid võib oluliselt parandada jõudlust, võimaldades suuremat tõelist paralleelsust.
Nende vahel valimine on kompromiss lihtsuse ja jõudluse vahel. Alustage jämedamate lukkudega ja liikuge peeneteralisemate lukkude juurde ainult siis, kui jõudlusprofiil näitab, et luku konkurents on kitsaskoht.
3. Vabastage alati oma lukud
Luku vabastamata jätmine on katastroofiline viga, mis tõenäoliselt peatab teie süsteemi. Selle vea tavaline allikas on see, kui kriitilises sektsioonis ilmneb erand või varajane tagastus. Selle vältimiseks kasutage alati keelekonstruktsioone, mis tagavad puhastuse, näiteks try...finally plokid Java või C#-s või RAII (Ressursi hankimine on initsialiseerimine) mustrid C++-s skoobitud lukkudega.
Näide (pseudokood kasutades try-finally):
my_lock.acquire();
try {
// Kriitilise sektsiooni kood, mis võib erandi visata
} finally {
my_lock.release(); // See täidetakse kindlasti
}
4. Järgige ranget luku järjekorda
Ummikseisude vältimiseks on kõige tõhusam strateegia murda ringootamistingimus. Kehtestage mitme luku hankimise jaoks range, globaalne ja suvaline järjekord. Kui lõim peab kunagi hoidma nii lukku A kui ka lukku B, peab ta alati hankima luku A enne luku B hankimist. See lihtne reegel muudab ringootamised võimatuks.
5. Kaaluge lukustamise alternatiive
Kuigi lukud on fundamentaalsed, ei ole need ainus lahendus samaaegsuse kontrollimiseks. Suure jõudlusega süsteemide puhul tasub uurida täiustatud tehnikaid:
- Lukuvabad andmestruktuurid: need on keerukad andmestruktuurid, mis on loodud madala taseme aatomi riistvarajuhiste (nagu Võrdle-ja-Vaheta) abil, mis võimaldavad samaaegset juurdepääsu ilma lukke kasutamata. Neid on väga raske õigesti rakendada, kuid need võivad pakkuda suurepärast jõudlust kõrge konkurentsi korral.
- Muutumatud andmed: kui andmeid pärast nende loomist kunagi ei muudeta, saab neid lõimede vahel vabalt jagada, ilma et oleks vaja sünkroonimist. See on funktsionaalse programmeerimise põhiprintsiip ja on üha populaarsem viis samaaegsete kujunduste lihtsustamiseks.
- Tarkvara tehingumälu (STM): kõrgema taseme abstraktsioon, mis võimaldab arendajatel määratleda mälus aatomitehinguid, sarnaselt andmebaasiga. STM-süsteem haldab keerulisi sünkroonimisdetaile kulisside taga.
Kokkuvõte
Lukupõhine sünkroonimine on samaaegse programmeerimise nurgakivi. See pakub võimsa ja otsese viisi jagatud ressursside kaitsmiseks ja andmete riknemise vältimiseks. Alates lihtsast muteksist kuni nüansseerituma lugemis-kirjutamis lukuni on need primitiivid olulised tööriistad igale arendajale, kes ehitab mitme keermega rakendusi.
Kuid see jõud nõuab vastutust. Potentsiaalsete lõksude – ummikseisude, elulukkude ja jõudluse halvenemise – sügav mõistmine ei ole valikuline. Järgides parimaid praktikaid, nagu kriitilise sektsiooni suuruse minimeerimine, sobiva luku granularsuse valimine ja range luku järjekorra jõustamine, saate rakendada samaaegsuse võimsust, vältides samal ajal selle ohte.
Samaaegsuse valdamine on teekond. See nõuab hoolikat disaini, ranget testimist ja mõtteviisi, mis on alati teadlik keerulistest interaktsioonidest, mis võivad tekkida, kui lõimed töötavad paralleelselt. Lukustamise kunsti valdades astute kriitilise sammu tarkvara ehitamise suunas, mis ei ole mitte ainult kiire ja reageeriv, vaid ka jõuline, usaldusväärne ja korrektne.